Ivyponic - A Smart Gardening Solution

ECE 5725 Fall 2022
Eric Moon and Vinay Bhamidipati
15 December 2022


Demonstration Video


Objective

Imagine fresh tomatoes, fresh herbs–any plant you can imagine, homegrown with just a press of a button. What if you could take a beautiful home garden and show it off inside the house? Ivyponic takes all of the difficulty and micromanagement out of home gardening while simultaneously being a beautiful, elegant showpiece for the house.

Introduction

During the semester, our team (Vinay Bhamidipati and Eric Moon) built Ivyponic. Ivyponic is a sealed, hydroponic indoor gardening solution that can automatically grow a plant under customizable conditions, including airflow, lighting, temperature, humidity, and nutrient density. Ivyponic grows plants without the need to micromanage or take care of the plants directly. We used the PiTFT screen to allow for customization of these conditions to optimize for a specific plant’s needs. We created an hydroponic growing system, using a pump to deliver fertilized water to plants, without the need of soil. We used the RPi to manage all variables for plant growth to maintain user-defined targets. We used PPM, temperature, and humidity sensors to gather the necessary environmental data.


eric box

Why Ivyponic?

  • Simplicity: removes gardening micromanagement
  • Control: decide exact growth parameters
  • Data Driven: set environment for optimal growth
  • Wow: it looks pretty cool!

Design

inital sketch

Inital Sketch of Ivyponic

final design

Final Picture of Ivyponic

Each phase of design for Ivyponic began with a drawing. During the ideation phase of Ivyponic, we envisioned the system as depicted in the drawing above. The Raspberry Pi and PiTFT would be mounted at the bottom of the enclosure, displaying temperature, humidity, airflow, and lighting parameters. Above the Raspberry Pi, there would be a catch pan that would route excess drip-off back into the water pump. The water reservoir would be located at the top of the enclosure, and the pump would have two valve-controlled spray heads–one for misting the enclosure and one for watering the plants. The system would be complete with humidity, temperature, and airflow sensors.

In the following phases, we dug deeper into the design specifics, using this initial drawing as a starting point. One constant in our design process was the chassis of Ivyponic. Because we wanted to design a home garden product that would look visually distinct and appealing, we decided to use clear acrylic. This allowed the plant and lighting to be a focal point of the aesthetics, while also allowing users to see the behind-the-scenes of the device; the electronics, wiring, and pump system were fully visible.

final sketch

Sketch of Interactive Menus




inital sketch

Status Menu

final design

Targets menu


final design

Lighting menu

final design

Presets menu

Another key point of design was the user interface on the PiTFT. Before coding the Raspberry Pi interface, we planned out each specific menu. At the top of each menu is a taskbar that displays the currently active menu. Additionally, there are forward, backward, and power icons that illustrate the functions of the adjacent buttons. The “Status” menu would display the current status of the enclosure including the current humidity, temperature, PPM, fan speed, and RGB values. These values would be retrieved from the various sensors, the fan, and the LED light strip, respectively. The next screen would allow the user to adjust the user-controlled targets to their desired values. For example, if the user wanted to maintain a temperature of 25 degrees celsius in the enclosure, he or she could set that from this menu. Next, the “Lighting” menu would allow the user to toggle the lights on and off for the various hours in the day. Finally, the “Presets'' menu would allow the user to select from hard-coded presets. Each preset contains the optimal values for temperature, humidity, PPM, and RGB for a specific plant. Upon selecting one of these presets, these optimal values would be set by the system, and the enclosure would attempt to maintain them.

final sketch

Final Sketch of Ivyponic

Concurrently while coding the interface, we planned out the construction of the actual enclosure. After realizing that our pump would not be able to activate the misters, we had to pivot to a drip-irrigation system as shown above. Once we came up with this design, building it out was relatively easy, even though we expected the physical construction to be the hardest part.

final design

Targets select menu

final design

Lighting select menu

final design

Presets select menu

In our original plans for Ivyponic, the display would be touchscreen, which influenced how we envisioned the user would interact with the device. However, upon implementing the menu interfaces, we discovered that our PiTFT touchscreen was non-functional, reporting random values for touch positions. Because of this, we had to redesign the display, coming up with UI design that did not require touch functionality. Eventually, we settled on the above system where each screen would have a select button that brought up a nested menu with new button functions. These functions would change based on which menu was being displayed. In the case of the first menu, the select menu was unnecessary, as the menu was for viewing status, not editing values. In the subsequent menus—targets, lighting, and presets—a select button was added. This would switch the current tab to a ‘selection mode’ where the side buttons functionality changed to allow for users to customize values. This would include a button to cycle through parameters and buttons to either toggle or increment values. In the place of a power button, a back button was added, to return to the menu to ‘viewing mode’.


Testing & Challenges

To aid with testing and in accordance with good coding practices, each module was factored out into its own file. This made testing the different sensors, the pump, the fan, and the LEDs relatively easy. First, we would test each component individually within its respective file under the “if __name__ == ‘__main__’:” line. Then, if it functioned correctly, we would simply instantiate an object of it in the main file. Since both the pump and the fan were driven from the motor controller, we instantiated them both as Motor objects and tested them within the motor.py file.

This testing method made it easy to narrow down whether the problems we had were caused by the individual components or rather the main code operation. One specific problem we ran into was that the fan and pump were not functioning properly within the main code. Because we had already tested them in motor.py, we knew it had to be a problem in main.py. We discovered that, because we were instantiating the fan and pump on new threads, eventually a thread would not have GPIO.BCM set which would cause the program to crash. To fix this problem, we modified the main code to only spawn one thread each for the fan and pump.

To diagnose hardware problems, before we ever ran code, we tested our hardware with benchtop EE tools including power supplies and oscilloscopes. Using this method, we determined that there were no hardware problems with the pump and fan we chose to use. Furthermore, we were able to determine that certain GPIO pins that we were using on our Raspberry Pi were dysfunctional.

One fundamental challenge we ran into was that the pressure our pump could generate was not sufficient to activate the misters that we had planned to use in our original design. This forced us to pivot to a drip-irrigation system that did not rely on sprayers. Although we considered using alternative pumps that could activate the sprayers, ultimately we decided not to use them due to time and budget constraints.

Challenges also arose in the implementation of the many sensors, motors, and lights all on the Raspberry Pi. Due to the large number of devices and the presence of several fried GPIO pins, wiring up the sensors was not easy. First, the PiTFT already uses many of the GPIO pins, and uses the SPI0 channel. To enable additional channels, /boot/config.txt was edited, but strange PiTFT behavior meant that certain SPI channels couldnt be used, or rebooting an indefinite number of times was required to eventually see SPI channels become enabled. The NeoPixel light strips also could only use specific GPIO pins, leading to many of the GPIO pins being shifted around, until all devices, specifically the PPM sensor (SPI) and temperature/humidity sensor (I2C), were all able to run correctly. After demoing with the temperature sensor, the I2C connection no longer appears stable, and running i2cdetect reveals that the connection seems to ‘flicker’, with Linux constantly detecting and losing connection to the AHT20.


Results

As mentioned, the challenges of the underpowered pump led to the redesign of the watering system. However, with the new drip watering system was equally effective. The final design worked as intended, with live updates of PPM, humidity, temperature, and lighting conditions being sent to the Raspberry Pi. Additionally, the gardening control system worked as expected, with notifications being displayed for high or low PPM/humidity, and fan speeds and lighting being appropriately adjusted based on temperature and light color/scheduling. One issue that appeared in the final presentation was the reading of the PPM value. In testing the PPM sensor, only solutions with PPM value less than 310 PPM were used in testing. However in the demo, a large amount of fertilizer was poured into the nutrient solution, resulting in a PPM maxing out at around 550 PPM. Addition of more fertilizer to the water did not affect PPM—the sensor could not read any larger values. In the future, this would need to be addressed, as many hydroponic systems perform optimally at PPM values up to 700-800. Additionally, issues with the AHT20 temperature and humidity sensor presented after the final demo. After successfully demoing the AHT20, the next day the temperature sensor seemed to have failed. As mentioned in the challenges section, it no longer can consistently communicate via I2C for more than a few seconds. Both these issues are fairly minor. A larger range PPM sensor and swapping the AHT20 for a new one would be simple solutions.

Overall, Ivyponic was a success—an automatic gardening solution powered by the Raspberry Pi. Even after a few days, our withered green onions looked rejuvenated.


Conclusions

In conclusion, Ivyponic is a mostly-automated gardening system, which demonstrates the potential to become fully automatic by improving cooling/airflow, automatic PPM balancing, and a sensor for root hydration. Designing and building Ivyponic led us to realize the true difficulty and complexity in building a system like this. How can we ensure the specific plant is getting the correct amount of water? How can ensure the plant gets enough oxygen while regulating temperature and humidity? These were all questions that were only partially answered in our final design; to fully implement our vision of Ivyponic, more sensors, electronics, testing, and data would be needed. With that being said, the process of designing and iterating on that design to build a final project taught us a lot about embedded systems and general product/system design. Adapting to issues like the underpowered pump and fried GPIO pins were critical to deliver the final demo, and helped us learn to think creatively and non-linearly.


Future Work

Software wise, there were a lot of things we would have loved to implement given extra time. For the presets menu, we would explore grabbing presets from an agricultural database. We would explore finer-grain control of the lighting, including options to have different color spectra for the different phases of a plant’s growth (e.g. flowering vs. vegetative) since each phase has slightly different optimal spectra needs. This data would be included in the presets. Furthermore, we would explore gradual dimming and brightening of the lights rather than the current binary implementation of either full or zero brightness. Hardware wise, we would explore implementing a moisture sensor within the growing medium to regulate the pump watering interval. We would redesign the enclosure such that it conceals the wiring that takes away from the beauty of Ivyponic. We would have routed the electrical connections outside of a non-removable panel, rather than the removable one. We would have implemented a mechanical system that would automatically dispense fertilizer, rather than requiring the user to enter fertilizer. Finally, we would have used a more robust growing medium since the one we used was fragile when wet.


Work Distribution

Generic placeholder image

Project Group Picture

eric image

Eric Moon

esm234@cornell.edu

Sensor Implementation | Chassis Construction | GUI

vinay image

Vinay Bhamidipati

vkb29@cornell.edu

Motor Implementation | Control System | GUI

Vinay was responsible for implementing the pump, fan, LEDs, & control system. Eric was responsible for implementing the temperature, humidity, & ppm sensors and performed much of the planning/sketching. We worked together on the chassis construction, GUI implementation, and the final report.

Parts List

Purchased:

From Lab Kit:

From Lab or Elsewhere:

Total: $50.00


References

MakerCase
Adafruit NeoPixel guide
Adafruit AHT20 guide
Pygame reference
Sparkfun motor controller reference
Sparkfun learn I2C and SPI
AHT20 Chouffy Github
Adafruit MCP3008 guide
CQC Robot PPM wiki
GreenOurPlanet - How to build simple aeroponic system
R-Pi GPIO Document

Code Appendix

#main.py

from dataclasses import dataclass
import RPi.GPIO as GPIO
import os
import pygame
from pygame.locals import *
import numpy
from time import perf_counter, sleep
from datetime import datetime
from sys import argv
from collections import deque
from threading import Thread

from PPM import PPM
import AHT20
from collections import deque
import sys
from motor import Motor
from LED import LED
from graphics import Graphics


print(sys.executable)
FULL_SPEED = 100
HALF_SPEED = 50


# GPIO pins for the A and B motors
AIN1 = 6
PUMP_PWM = 4
BIN1 = 16
FAN_PWM = 24

'''
@dataclass
class Graphics:
    # Colors
    WHITE = 255, 255, 255
    BLACK = 0, 0, 0
    RED = 255, 0, 0
    GREEN = 200, 90, 10
    BLUE = 0, 0, 255
    ORANGE = 234, 163, 50
    BLUISH = 45, 175, 220
    GREENISH = 45, 220, 110
    # Fonts
    menu_button_font = None
    display_font = None

    # Screen variables
    screen = None

    def initialize_fonts(self):
        self.menu_button_font = pygame.font.Font(None, 20)
        self.display_font = pygame.font.Font(None, 30)
'''

@dataclass
class Presets:
    TOMATO = {'temp': 21, 'humidity': 75,
              'ppm': 700, 'r': 255, 'g': 0, 'b': 255}
    CILANTRO = {'temp': 19, 'humidity': 30,
                'ppm': 500, 'r': 100, 'g': 100, 'b': 100}
    BASIL = {'temp': 25, 'humidity': 50,
             'ppm': 700, 'r': 140, 'g': 0, 'b': 255}
    POTATO = {'temp': 18, 'humidity': 50,
              'ppm': 700, 'r': 200, 'g': 20, 'b': 140}

class Interface:
    pos = None
    selected: deque = deque()
    def update(self):
        self.pos = None

class Interface1(Interface):
    pass

class Interface2(Interface):
    selected: deque[int] = deque([1,0,0,0,0,0])
    keys: list[str] = ["humidity", "temp", "ppm", "r", "g", "b"]
    blink: int = 0

    def update(self):
        super().update()
     

class Interface3(Interface):
    AM: bool = True
    selected: deque[int] = deque([1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0])

class Interface4(Interface):
    selected = deque([1,0,0,0])
    presets = [Presets.CILANTRO,Presets.POTATO, Presets.BASIL, Presets.TOMATO]
    
@dataclass
class State:
    # storing current values of environmental parameters
    humidity: float = 0
    temperature: float = 0
    rpm: float = 0
    ppm: float = 0

    # targets
    targets = {"humidity": 0.0, "temp": 0.0,
               "ppm": 0.0, "r": 0.0, "g": 0.0, "b": 0.0}
    increments = {"humidity": 1, "temp": 1, "ppm": 10, "r": 3, "g": 3, "b": 3}

    # parameters to store for LED
    light_schedule = [0]*24

    # menu handling
    menu: int = 1
    interface = Interface()
    select = False

    t0 = perf_counter()


    def __init__(self):
        self.PPM_sensor = PPM()
        self.AHT20 = AHT20.AHT20(BusNum=1)
        self.controller:Controller = None

    # updating commands
    def update(self):
        self.humidity = self.AHT20.get_humidity()  # read_humidity
        self.temperature = self.AHT20.get_temperature() # read_temperature
        self.rpm = self.controller.fan_speed  #not actually rpm, actually duty cycle (%) of fan
        
        ppm_val = self.PPM_sensor.read()
        self.ppm = ppm_val if ppm_val != -1 else self.ppm # read_ppm

    def increment_target(self, target, multiplier):
        increment = self.increments[target]
        new_val = self.targets[target] + multiplier*increment

        if target == "humidity":
            if new_val < 10:
                new_val = 10
            elif new_val > 100:
                new_val = 100

        elif target == "temp":
            if new_val > 28:
                new_val = 28
            elif new_val < 16:
                new_val = 16

        elif target == "ppm":
            if new_val > 1200:
                new_val = 1200
            elif new_val < 350:
                new_val = 350

        elif target in {"r", "g", "b"}:
            if new_val > 255:
                new_val = 255
            elif new_val < 0:
                new_val = 0

        self.targets[target] = new_val
        if target in {"r", "g", "b"}: 
            color = self.targets['r'], self.targets['g'], self.targets['b']
            self.controller.update_LED(color)


class Controller: 
    #motors
    #PUMP = motor.Motor("Pump", PUMP_PWM, AIN1)
    #FAN = motor.Motor("Fan", FAN_PWM, BIN1)

    #parameters
    fan_speed = 0 #duty cycle (%) for fan 
    control = False



    def __init__(self, STATE): 
        self.STATE:State = STATE
        self.STATE.controller = self
        color = self.STATE.targets["r"], self.STATE.targets["g"], self.STATE.targets["b"]
        
        self.LED_STRIP = LED(color)
        self.PUMP = Motor("Pump", PUMP_PWM, AIN1, offset=-1)
        self.FAN = Motor("Fan", FAN_PWM, BIN1)
    

    def start(self):
        Controller.control = True

        #create threads
        fan_thread = Thread(target=self.control_fan, daemon=True)
        pump_thread = Thread(target=self.control_pump, daemon=True)
        LED_thread = Thread(target=self.control_LED, daemon=True)

        #start threads
        fan_thread.start()
        pump_thread.start()
        LED_thread.start()


    def stop(self): 
        Controller.control = False


    def control_fan(self):
        #drive fan indefinitely 
        self.FAN.drive(self.fan_speed,-1)

        while(Controller.control):
            target_temp = self.STATE.targets["temp"]
            if self.STATE.temperature < target_temp: 
                #decrease fan speed
                self.update_fan_speed(-1)
            elif self.STATE.temperature > target_temp: 
                #increase fan speed
                self.update_fan_speed(1)

            sleep(3)

    def control_pump(self):
        #drive pump indefinitely 
        self.PUMP.drive(0, -1)

        while(Controller.control): 
            #run pump at 100% for 30 seconds
            self.PUMP.setSpeed(100)
            sleep(30)
            #stop pump for 90 seconds
            self.PUMP.setSpeed(0)
            sleep(90)
    
    def control_LED(self): 
        while(Controller.control): 
            time = datetime.now().time()
            time_index = time.hour - 1
            color: tuple[float, float, float] = self.STATE.targets['r'], self.STATE.targets['g'], self.STATE.targets['b']
            self.LED_STRIP.color =  color

            print(f"Time: {time}")
            if(self.STATE.light_schedule[time_index]): 
                print("LEDs: Turning on lights")
                self.LED_STRIP.turnon()
            else: 
                print("LEDs: Shutting off lights")
                self.LED_STRIP.shutoff()

            
            sleep(60)

    def update_fan_speed(self, multiplier): 
        '''multiplier = 1 or -1
        '''
        increment = 10 * multiplier
        new_speed = self.fan_speed + increment

        if new_speed > 100: 
            new_speed = 100 
        elif new_speed < 0: 
            new_speed = 0 
        
        self.fan_speed = new_speed

        self.FAN.setSpeed(new_speed)

        

    def update_LED(self, color): 
        self.LED_STRIP.color = color 
        time = datetime.now().time()
        time_index = time_index = time.hour - 1
        if(self.STATE.light_schedule[time_index]): 
            self.LED_STRIP.fill(color)


def b17_callback(channel):
    '''
    Moves menu forward
    Performs 1st button function in inner menu 

    '''
    print("FWD")
    if not STATE.select: 

        if STATE.menu < 4:
            STATE.menu += 1
            STATE.interface = create_interface(STATE.menu)
        else:
            STATE.menu = 1
    else: 
        if(STATE.menu == 2): 
            #cycle forward
            STATE.interface.selected.rotate()
        elif(STATE.menu == 3):
            #cycle through 
            STATE.interface.selected.rotate()
        elif(STATE.menu == 4): 
            #cycle forward
            STATE.interface.selected.rotate()


def b22_callback(channel):
    '''
    Moves menu backward in outer menu
    Performs 2nd button function in inner menu
    '''
    print("BACK")
    
    if not STATE.select: 
        if STATE.menu > 1:
            STATE.menu -= 1
            STATE.interface = create_interface(STATE.menu)
        else:
            STATE.menu = 4
    else: 
        if(STATE.menu == 2): 
            #increment
            ind = STATE.interface.selected.index(1)
            targ = STATE.interface.keys[ind]
            STATE.increment_target(targ, 1)
            pass
        elif(STATE.menu == 3):
            #cycle backward
            STATE.interface.selected.rotate(-1)
            pass
        elif(STATE.menu == 4): 
            #cycle backward
            STATE.interface.selected.rotate(-1) 
            pass


def b23_callback(channel):
    """Toggles select in outer menu
        3rd button function in inner menu

    """
    if STATE.menu in {2,3,4}:
        if not STATE.select: 
            STATE.select = True
        elif(STATE.menu == 2): 
            #decrement
            ind = STATE.interface.selected.index(1)
            targ = STATE.interface.keys[ind]
            STATE.increment_target(targ, -1)
            pass
        elif(STATE.menu == 3):
            #toggle on and off
            ind = STATE.interface.selected.index(1)
            STATE.light_schedule[ind] = 0 if STATE.light_schedule[ind] else 1
            pass
        elif(STATE.menu == 4): 
            #enter 
            ind = STATE.interface.selected.index(1)
            STATE.targets = STATE.interface.presets[ind]
            color = STATE.targets['r'], STATE.targets['g'], STATE.targets['b']
            STATE.controller.update_LED(color)


def b27_callback(channel):
    '''
    Powers off in outer menu
    Exits select menu in inner menu

    '''
    if not STATE.select: 
        shutdown()
    else: 
        STATE.select = False


def startup():
    '''
    To be called at the start of the program
    Sets up GPIO inputs and attaches their callbacks

    '''

    GPIO.setmode(GPIO.BCM)
    GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_UP)
    GPIO.setup(22, GPIO.IN, pull_up_down=GPIO.PUD_UP)
    GPIO.setup(23, GPIO.IN, pull_up_down=GPIO.PUD_UP)
    GPIO.setup(27, GPIO.IN, pull_up_down=GPIO.PUD_UP)
    GPIO.add_event_detect(
        17, GPIO.FALLING, callback=b17_callback, bouncetime=300)
    GPIO.add_event_detect(
        22, GPIO.FALLING, callback=b22_callback, bouncetime=300)
    GPIO.add_event_detect(
        23, GPIO.FALLING, callback=b23_callback, bouncetime=300)
    GPIO.add_event_detect(
        27, GPIO.FALLING, callback=b27_callback, bouncetime=300)


def shutdown():
    '''
    Function to be called whenever a program ends

    Cleans up GPIO and sets code_run to false. 
    Exits Python. 
    '''
    print("Shutting down...")
    global code_run
    code_run = False




def outputs(pin_list):
    '''Sets up the pin numbers in pin_list as GPIO outputs
    '''
    for pin in pin_list:
        GPIO.setup(pin, GPIO.OUT)


def inputs(pin_list):
    '''Sets up the pin numbers in pin_list as GPIO outputs
        '''
    for pin in pin_list:
        GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)


def distance(p1, p2):
    return ((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)**(1/2)

def draw_text(position,text,color, corner="center", font="menu_button"):
    if font == "menu_button": 
        text_surface = GRAPHICS.menu_button_font.render(text, True, color)
    elif font == "small_text": 
        text_surface = GRAPHICS.small_font.render(text, True, color)
    elif font == "display": 
        text_surface = GRAPHICS.display_font.render(text, True, color)
    if corner == "center": 
        rect = text_surface.get_rect(center=position)
    elif corner == "topleft": 
        rect = text_surface.get_rect(topleft=position)


    GRAPHICS.screen.blit(text_surface, rect)

def draw_arrow_down(point,color):
    arrow_top = (point[0],point[1]-9)
    arrow_left = (point[0]-3,point[1]-3)
    arrow_right = (point[0]+3,point[1]-3)

    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_top, 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_left, 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_right, 2)

def draw_arrow(point,color):
    arrow_top = (point[0],point[1]+9)
    arrow_left = (point[0]-3,point[1]+3)
    arrow_right = (point[0]+3,point[1]+3)

    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_top, 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_left, 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.RED, point, arrow_right, 2)

def collidepoint(rect, pt):
    """rect - (x topleft, y topleft, width, height)
    pt - (x,y)
    """
    xtopleft, ytopleft, width, height = rect
    x,y = pt

    collide = False
    if x >= xtopleft and x <= xtopleft + width and y >= ytopleft and y <= ytopleft + height: 
        collide = True

    return collide

def menu(fwd=True, back=True, power=True):
    """Super class menu 

    Draws top menu display, forward, back buttons, and power

    """
    # highlight appropriate tab
    left = 80*STATE.menu - 78
    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.GREEN, (left, 2, 78, 27), 0)

    if not STATE.select: 
        # draw button text
        draw = [fwd, back, power]
        button_texts = ["->", "<-", "PWR"]
        button_positions = [(300, 49), (300, 111), (300, 227)]
        for d, text, pos in zip(draw, button_texts, button_positions):
            if d:
                text_surface = GRAPHICS.menu_button_font.render(text, True, GRAPHICS.WHITE)
                rect = text_surface.get_rect(center=pos)
                GRAPHICS.screen.blit(text_surface, rect)
        if STATE.menu in {2,3,4}: 
            text_surface = GRAPHICS.menu_button_font.render("select", True, GRAPHICS.WHITE)
            rect = text_surface.get_rect(center = (296, 170))
            GRAPHICS.screen.blit(text_surface, rect)

    # draw top menu text
    menu_texts = ["Status", "Targets", "Lighting", "Presets"]
    menu_positions = [(40, 15), (120, 15), (200, 15), (280, 15)]

    for text, pos in zip(menu_texts, menu_positions):
        text_surface = GRAPHICS.menu_button_font.render(text, True, GRAPHICS.WHITE)
        rect = text_surface.get_rect(center=pos)
        GRAPHICS.screen.blit(text_surface, rect)


    # draw top menu lines/boxes
    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.BLUISH, (0, 0, 320, 30), 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.BLUISH, (80, 30), (80, 0), 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.BLUISH, (160, 30), (160, 0), 2)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.BLUISH, (240, 30), (240, 0), 2)


def menu1():
    """Draws the current status
    """

    menu()

    texts = [f"Humidity: {STATE.humidity:.01f}%", f"Temp: {STATE.temperature:.01f}C", f"PPM: {STATE.ppm:.01f} ppm",
             f"Fan Speed: {STATE.rpm:.01f}%", f'R: {round(STATE.targets["r"] *100/ 255, 1)}% G: {round(STATE.targets["g"] *100/255, 1)}% B: {round(STATE.targets["b"] *100/255, 1)}%']
    positions = [(10, 50), (10, 90), (10, 130), (10, 170), (10, 210)]

    for text, pos in zip(texts, positions):
        draw_text(pos, text, GRAPHICS.WHITE, corner="topleft", font="display")

    if STATE.humidity < STATE.targets['humidity']: 
        draw_text((170, 60), "(LOW: MIST ENCLOSURE!)", GRAPHICS.RED, corner="topleft", font="small_text")


    if STATE.ppm < STATE.targets['ppm']: 
        draw_text((170, 140), "(LOW: ADD FERTILIZER!)", GRAPHICS.RED, corner="topleft", font="small_text")



def menu2():
    """Targets

    Set desired targets that the embedded system will attempt to maintain

    """
    humidity_pos = (10, 50, 150, 20)
    temp_pos =(10, 90, 120, 20)
    ppm_pos =(10, 130, 120, 20)

    menu()


    texts = [f"Humidity: {STATE.targets['humidity']:.01f}%", f"Temp: {STATE.targets['temp']:.01f}F",
             f"PPM: {STATE.targets['ppm']:.01f} ppm", f"R: {STATE.targets['r'] *100/ 255:.1f}%" ,f"G: {STATE.targets['g'] *100/255:.1f}%",f"B: {STATE.targets['b'] *100/255:.1f}%"]
    positions = [(10, 50), (10, 100), (10, 150), (10, 200), (100,200),(190,200)]
    #selected = [t == STATE.interface.selected for t in STATE.targets.keys()]

    

    if STATE.select: 
        for text, loc, s in zip(texts, positions, STATE.interface.selected):
            text_surface = GRAPHICS.display_font.render(
                text, True, GRAPHICS.BLUE if s else GRAPHICS.WHITE)  # highlight
            rect = text_surface.get_rect(topleft=loc)
            GRAPHICS.screen.blit(text_surface, rect)
        button_positions = [(295, 49), (281, 111), (281, 170), (297, 227)]
        button_texts = ["cycle", "increment", "decrement", "back"]
        for p, txt in zip(button_positions, button_texts): 
            draw_text(p, txt, GRAPHICS.WHITE)
    else: 
        for text, loc in zip(texts, positions):
            text_surface = GRAPHICS.display_font.render(text, True, GRAPHICS.WHITE)  # highlight
            rect = text_surface.get_rect(topleft=loc)
            GRAPHICS.screen.blit(text_surface, rect)
        
    '''
    if STATE.interface.selected:
        pygame.draw.polygon(GRAPHICS.screen, GRAPHICS.ORANGE,
                            ((200, 110), (210, 100), (220, 110)))  # up arrow
        pygame.draw.polygon(GRAPHICS.screen, GRAPHICS.ORANGE, ((
            200, 130), (210, 140), (220, 130)))  # down arrow
    '''


def menu3():
    """Lighting

    View current lighting settings, change lighting schedule 

    """
    menu() #print menu top label and buttons
    schedule = STATE.light_schedule

    if(STATE.select): #draw menu3 select screen button indicators
        draw_text(position=(300,49),text='>>',color=GRAPHICS.WHITE)
        draw_text(position=(300,111),text='<<',color=GRAPHICS.WHITE)
        draw_text(position=(295,170),text='toggle',color=GRAPHICS.WHITE)
        draw_text(position=(295,227),text='back',color=GRAPHICS.WHITE)

    #draw PM/AM title
    draw_text((60,70),'AM Schedule',color=GRAPHICS.WHITE)
    draw_text((60,150),'PM Schedule',color=GRAPHICS.WHITE)

    #draw schedule graphic
    l1 = (20, 110, 260, 110)
    l2 = (20, 190, 260, 190)

    pygame.draw.line(GRAPHICS.screen, GRAPHICS.GREEN, l1[0:2], l1[2:4], 3)
    pygame.draw.line(GRAPHICS.screen, GRAPHICS.GREEN, l2[0:2], l2[2:4], 3)
    lpoint = (40, 110, 190)
   
    for i in range(11):
        m=2 if i % 2 == 0 else 3
        pygame.draw.line(GRAPHICS.screen, GRAPHICS.GREEN, (lpoint[0]+i*20, lpoint[1]), (lpoint[0]+i*20, lpoint[1]-10*m),2)
        pygame.draw.line(GRAPHICS.screen, GRAPHICS.GREEN, (lpoint[0]+i*20, lpoint[2]), (lpoint[0]+i*20, lpoint[2]-10*m),2)

    #draw lighting timeslots on graphic
    top_left = [22,94]
    width_height = [18,15]
    for i, val in enumerate(schedule):
        if i == 12:
            top_left = [22,174]
            
        if val == 1:
            pygame.draw.rect(GRAPHICS.screen, GRAPHICS.GREENISH, top_left+width_height, 0)
            
        top_left[0]+=20

    #if select menu is chosen
    if(STATE.select):
        current = STATE.interface.selected.index(1)
        arrow_point = (30+20*current,120) if current < 12 else (30+20*(current-12),200)
        draw_arrow(arrow_point,color=GRAPHICS.RED)
        
    

def menu4():
    """Presets
    """

    menu()

    x_init = 10
    y_init = 35

    width = 100
    spacing = 5

    rect1 = (x_init, y_init, width, width)
    rect2 = (x_init + width + spacing, y_init, width, width)
    rect3 = (x_init, y_init + width + spacing, width, width)
    rect4 = (x_init + width + spacing, y_init + width + spacing, width, width)

    texts = ['Cilantro', 'Potato', 'Basil', 'Tomato']
    positions = [(rect1[0]+3, rect2[1]+2), (rect2[0]+3, rect2[1]+2),
                 (rect3[0]+3, rect3[1]+2), (rect4[0]+3, rect4[1]+2)]

    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.ORANGE, rect1, 3)
    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.ORANGE, rect2, 3)
    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.ORANGE, rect3, 3)
    pygame.draw.rect(GRAPHICS.screen, GRAPHICS.ORANGE, rect4, 3)

    #draw presets targets
    presets = [Presets.CILANTRO, Presets.POTATO, Presets.BASIL, Presets.TOMATO]
    for preset, name, pos in zip(presets, texts, positions):
        draw_text((pos[0]+10,pos[1]+20),f'Temp: {preset["temp"]}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")
        draw_text((pos[0]+10,pos[1]+32),f'Humidity: {preset["humidity"]}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")
        draw_text((pos[0]+10,pos[1]+44),f'PPM: {preset["ppm"]}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")
        draw_text((pos[0]+10,pos[1]+56),f'{color_to_percent(preset["r"], "R")}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")
        draw_text((pos[0]+10,pos[1]+68),f'{color_to_percent(preset["g"], "G")}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")
        draw_text((pos[0]+10,pos[1]+80),f'{color_to_percent(preset["b"],"B")}', color=GRAPHICS.BLUISH, corner="topleft", font="small_text")

    
    if not STATE.select: 
        for text, loc in zip(texts, positions):
            draw_text(loc, text, GRAPHICS.WHITE, corner="topleft")

    else: 
        for text, loc, s in zip(texts, positions, STATE.interface.selected):
            draw_text(loc, text, GRAPHICS.BLUE if s else GRAPHICS.WHITE, corner="topleft")

    if STATE.select: 
        button_positions = [(300, 49), (300, 111), (300, 170), (300, 227)]
        button_texts = ["->", "<-", "enter", "back"]
        for p, txt in zip(button_positions, button_texts): 
            draw_text(p, txt, GRAPHICS.WHITE)

# state variable to keep track of motor history
STATE = State()

# graphics variable to store data about graphics
GRAPHICS = Graphics()

#Timeout time
TIMEOUT = 300


def get_color_string(r, g, b): 
        return f'R: {round(r *100/ 255, 1)}% G: {round(g*100/255, 1)}% B: {round(b*100/255, 1)}%'
        return f'{round(r *100/ 255, 1)}%', f'G: {round(g*100/255, 1)}%', f'B: {round(b*100/255, 1)}%'
def color_to_percent(color_val, color_str): 
    return f'{color_str}: {round(color_val *100/ 255, 1)}%'


def main():
    startup()  # initialize gpio pins
    global code_run
    code_run = True

    # setting up a global timeout
    t0 = perf_counter()

    # setting up GPIO inputs and outputs, pwm signals
    inputs([])
    outputs([])

    # read initial parameters:
    with open("targets.txt", 'r') as f:
        lines = [l.strip().split() for l in f.readlines()]
        STATE.targets = {l[0]: float(l[1]) for l in lines}

        print(lines)
        print(STATE.targets)

    with open("lighting.txt", 'r') as f:
        STATE.light_schedule = [int(b) for b in f.readline().strip()]

    # START OF GAME LOOP

    # pygame setup
    os.putenv('SDL_VIDEODRIVER', 'fbcon')  # Display on piTFT
    os.putenv('SDL_FBDEV', '/dev/fb0')
    os.putenv('SDL_MOUSEDRV', 'TSLIB')  # Track mouse clicks on piTFT
    os.putenv('SDL_MOUSEDEV', '/dev/input/touchscreen')
    pygame.init()
    size = width, height = 320, 240
    clk = pygame.time.Clock()
    screen = pygame.display.set_mode(size)
    pygame.mouse.set_visible(False)
    
    GRAPHICS.set_screen(screen)
    GRAPHICS.initialize_fonts()

    screen.fill(GRAPHICS.BLACK)  # erase workspace
    pygame.display.flip()

    controller = Controller(STATE)
    controller.start()
    
    while(code_run is True and perf_counter() - t0 < TIMEOUT):
        screen.fill(GRAPHICS.BLACK)  # erase workspace

        STATE.update()


        if STATE.menu == 1:
            menu1()
        elif STATE.menu == 2:
            menu2()
        elif STATE.menu == 3:
            menu3()
        elif STATE.menu == 4:
            menu4()
     
        STATE.interface.update()

        for event in pygame.event.get():
            if event.type == MOUSEBUTTONUP:
                pos = pygame.mouse.get_pos()  # get position of touch
                STATE.interface.pos = pos
                break

        pygame.display.flip()
        clk.tick(60)

    # save last parameters
    with open("targets.txt", 'w') as f:
        f.write(f'humidity {STATE.targets["humidity"]}\ntemp {STATE.targets["temp"]}\nppm {STATE.targets["ppm"]}\nr {STATE.targets["r"]}\ng {STATE.targets["g"]}\nb {STATE.targets["b"]}')

    with open("lighting.txt", 'w') as f:
        print("WRITING")
        for b in STATE.light_schedule:
            f.write(str(b))

    if(t0 > TIMEOUT): 
        print("TIMED OUT")
    try: 
        shutdown()
    except: 
        print("A little longer...")

    controller.LED_STRIP.shutoff()
    GPIO.cleanup()


def create_interface(interface_num):
    if interface_num == 1:
        return Interface1()
    elif interface_num == 2:
        return Interface2()
    elif interface_num == 3:
        return Interface3()
    elif interface_num == 4:
        return Interface4()

if __name__ == "__main__":
    main()


#-------------------graphics.py----------------


from dataclasses import dataclass
import pygame


@dataclass
class Graphics:
    # Colors
    WHITE = 255, 255, 255
    BLACK = 0, 0, 0
    RED = 255, 0, 0
    GREEN = 200, 90, 10
    BLUE = 0, 0, 255
    ORANGE = 234, 163, 50
    BLUISH: tuple[Literal[45], Literal[175], Literal[220]] = 45, 175, 220
    GREENISH = 45, 220, 110


  
    def set_screen(self, screen):  
        self.screen = screen

    def initialize_fonts(self):
        self.menu_button_font = pygame.font.Font(None, 20)
        self.display_font = pygame.font.Font(None, 30)
        self.small_font = pygame.font.Font(None,15)


#-----------------------AHT20.py----------------

from smbus2 import SMBus
import time

def get_normalized_bit(value, bit_index):
    # Return only one bit from value indicated in bit_index
    return (value >> bit_index) & 1

AHT20_I2CADDR = 0x38
AHT20_CMD_SOFTRESET = [0xBA]
AHT20_CMD_INITIALIZE = [0xBE, 0x08, 0x00]
AHT20_CMD_MEASURE = [0xAC, 0x33, 0x00]
AHT20_STATUSBIT_BUSY = 7                    # The 7th bit is the Busy indication bit. 1 = Busy, 0 = not.
AHT20_STATUSBIT_CALIBRATED = 3              # The 3rd bit is the CAL (calibration) Enable bit. 1 = Calibrated, 0 = not

class AHT20:
    # I2C communication driver for AHT20, using only smbus2

    def __init__(self, BusNum=1):
        # Initialize AHT20
        self.BusNum = BusNum
        self.cmd_soft_reset()

        # Check for calibration, if not done then do and wait 10 ms
        if not self.get_status_calibrated == 1:
            self.cmd_initialize()
            while not self.get_status_calibrated() == 1:
                time.sleep(0.01)
        
    def cmd_soft_reset(self):
        # Send the command to soft reset
        with SMBus(self.BusNum) as i2c_bus:
            i2c_bus.write_i2c_block_data(AHT20_I2CADDR, 0x0, AHT20_CMD_SOFTRESET)
        time.sleep(0.04)    # Wait 40 ms after poweron
        return True

    def cmd_initialize(self):
        # Send the command to initialize (calibrate)
        with SMBus(self.BusNum) as i2c_bus:
            i2c_bus.write_i2c_block_data(AHT20_I2CADDR, 0x0 , AHT20_CMD_INITIALIZE)
        return True

    def cmd_measure(self):
        # Send the command to measure
        with SMBus(self.BusNum) as i2c_bus:
            i2c_bus.write_i2c_block_data(AHT20_I2CADDR, 0, AHT20_CMD_MEASURE)
        time.sleep(0.08)    # Wait 80 ms after measure
        return True

    def get_status(self):
        # Get the full status byte
        with SMBus(self.BusNum) as i2c_bus:
            return i2c_bus.read_i2c_block_data(AHT20_I2CADDR, 0x0, 1)[0]
        return True

    def get_status_calibrated(self):
        # Get the calibrated bit
        return get_normalized_bit(self.get_status(), AHT20_STATUSBIT_CALIBRATED)

    def get_status_busy(self):
        # Get the busy bit
        return get_normalized_bit(self.get_status(), AHT20_STATUSBIT_BUSY)
            
    def get_measure(self):
        # Get the full measure

        # Command a measure
        self.cmd_measure()

        # Check if busy bit = 0, otherwise wait 80 ms and retry
        while self.get_status_busy() == 1:
            time.sleep(0.08) # Wait 80 ns
        
        # Read data and return it
        with SMBus(self.BusNum) as i2c_bus:
            return i2c_bus.read_i2c_block_data(AHT20_I2CADDR, 0x0, 7)

    def get_temperature(self):
        # Get a measure, select proper bytes, return converted data
        measure = self.get_measure()
        measure = ((measure[3] & 0xF) << 16) | (measure[4] << 8) | measure[5]
        measure = measure / (pow(2,20))*200-50
        return measure

    def get_humidity(self):
        # Get a measure, select proper bytes, return converted data
        measure = self.get_measure()
        measure = (measure[1] << 12) | (measure[2] << 4) | (measure[3] >> 4)
        measure = measure * 100 / pow(2,20)
        return measure

if __name__ == "__main__":
    aht = AHT20(1)
    print(aht.get_temperature(), aht.get_humidity())

#-------------------LED.py-------------------

import board
import neopixel
from graphics import Graphics
from time import sleep
import RPi.GPIO as GPIO

class LED: 
    num_pixels = 50
    D_PIN = board.D21
    
    def __init__(self, color): 
        self.pixels = neopixel.NeoPixel(LED.D_PIN, LED.num_pixels)
        self.color = color
        #self.pixels.fill(self.color)

    def fill(self, color): 
        '''color = (r,g,b)'''
        for i in range(LED.num_pixels):
            self.pixels[i] = color 
            
        self.color = color

    def shutoff(self): 
        for i in range(LED.num_pixels): 
            self.pixels[i] = Graphics.BLACK
    def turnon(self): 
        for i in range(LED.num_pixels): 
            self.pixels[i] = self.color

if __name__ == "__main__": 
    #pixels.fill((0xFF, 0,0))
    #led = LED((255,0,255))

    n = 50
    pixels = neopixel.NeoPixel(board.D21, n)
    for i in range(n):
        pixels[i] = (255,0,0)
    sleep(2)

    #pixels.deinit()
    
    #pixels[4] = (255,0,255, 0)
    #pixels.fill((255, 0, 255))
    #pixels.fill((255,0,255))
    #led.turnon()

    '''GPIO.setmode(GPIO.BCM)
    GPIO.setup(12, GPIO.OUT)
    GPIO.output(12, GPIO.HIGH)
    sleep(2)
    GPIO.output(12, GPIO.LOW)
    GPIO.cleanup()
    '''


#---------------------motor.py--------------------

import RPi.GPIO as GPIO
import os
from threading import Timer
from time import sleep


class Motor:
    def __init__(self, name, PWM_PIN, IN1, IN2 = -1, offset = 1):
        self.name = name
        self.IN1 = IN1
        self.IN2 = IN2
        self.PWM_PIN = PWM_PIN
        self.offset = offset

        # setup pwm
        GPIO.setup(PWM_PIN, GPIO.OUT)
        GPIO.setup(IN1, GPIO.OUT)
        if IN2 != -1: 
            GPIO.setup(IN2, GPIO.OUT)
        self.PWM = GPIO.PWM(PWM_PIN, 50)  # 50Hz pwm
        self.PWM.start(0)

    def drive(self, dc, time):
        """time = time to drive, if -1, drive indefinitely"""
        print(f"{self.name}: DRIVING")
        sig1 = GPIO.HIGH if self.offset == 1 else GPIO.LOW
        sig2 = GPIO.LOW if self.offset == 1 else GPIO.HIGH

        GPIO.output(self.IN1, sig1)
        if self.IN2 != -1: 
            GPIO.output(self.IN2, sig2)

        self.PWM.ChangeDutyCycle(dc)
        if time != -1: 
            t = Timer(time, Motor.stop, args=[self])
            t.setDaemon(True)
            t.start()

    def setSpeed(self, dc): 
        print(f"{self.name}: NEW SPEED {dc}")
        self.PWM.ChangeDutyCycle(dc) 


    def stop(self):
        print(f"{self.name}: STOPPING")

        sig1 = GPIO.LOW if self.offset == 1 else GPIO.HIGH
        sig2 = GPIO.LOW if self.offset == 1 else GPIO.HIGH

        GPIO.output(self.IN1, sig1)
        if self.IN2 != -1: 
            GPIO.output(self.IN2, sig2)
        self.PWM.ChangeDutyCycle(100)

if __name__ == "__main__": 
    GPIO.setmode(GPIO.BCM)

    # GPIO pins for the A and B motors
    AIN1 = 6
    PUMP_PWM = 4
    BIN1 = 16
    FAN_PWM = 24

    fan = Motor("fan", FAN_PWM, BIN1)
    pump = Motor("pump", PUMP_PWM, AIN1, offset=-1)

    fan.drive(50, 5)
    sleep(2)
    fan.setSpeed(100)
    sleep(5)
    pump.drive(100, 10)
    sleep(12)
    GPIO.cleanup()


#-------------------PPM.py----------------------



import busio
import digitalio
import board
import adafruit_mcp3xxx.mcp3008 as MCP
from adafruit_mcp3xxx.analog_in import AnalogIn
from statistics import median

class PPM: 
    #parameters
    temperature_celcius = 25
    buffer_length = 50
    PPM_setup = False
    analog_buffer = []


    def __init__(self): 
        #create SPI5 bus (SPI0 used by PiTFT)
        self.spi5 = busio.SPI(clock=board.D15,MISO=board.D13,MOSI=board.D14)

        #create CS (chip select)
        self.cs = digitalio.DigitalInOut(board.D26)

        #create MCP object
        self.mcp = MCP.MCP3008(self.spi5,self.cs)

        #create analog input channel on pin 0
        self.channel0 = AnalogIn(self.mcp,MCP.P0)

    def read(self): 
        #while len(analog_buffer) < buffer_length:
        analog_value = self.channel0.value #get sample
        self.analog_buffer.append(analog_value) #add sample to buffer
        #print('sample #{length}: {val}'.format(length=len(analog_buffer), val=analog_value)) 
        
        if len(self.analog_buffer) == self.buffer_length: #once 20 samples, get median, repeat
            average_voltage = median(self.analog_buffer) / (2**15) #constant is arbitrary (given const didnt work)
            self.analog_buffer = []

            #TDS value calclations
            compensation_coefficient = 1.0 + 0.02 * (self.temperature_celcius - 25.0)
            compensation_voltage = average_voltage / compensation_coefficient
            tds_value:float = (((133.42*compensation_voltage**3) - (255.86*compensation_voltage**2) + (857.39*compensation_voltage))*.5)	
            return tds_value
        else: 
            return -1


#----------------------lighting.txt-----------------
000000000000000000000000
#--------------------Targets.txt--------------------
humidity 40 
temp 80
ppm 5
r 0
g 0
b 0